fix(provider): use a larger stall budget while a Cursor tool is in flight - #86
Merged
Conversation
…ight
The stream watchdog re-armed only from push(), which fires for the ten
mapped update types. A long shell command, build, or test suite streams
nothing between tool-call-started and tool-call-completed, so a healthy
run was cancelled at the 60s budget and the turn lost.
Split the budget in two: an idle budget (OPENCODE_CURSOR_STALL_MS,
raised 60000 -> 120000) and a tool-phase budget applied while at least
one tool call is open (OPENCODE_CURSOR_TOOL_STALL_MS, default 600000,
0 disables just that bound). A tool-phase stall stays terminal and now
names the tool still in flight.
Also re-arm on any raw SDK update rather than only mapped ones, so
progress/heartbeat types the plugin does not model still count as
liveness. The re-arm runs after the switch so it observes post-mutation
openTools state and always selects the correct budget.
Reconcile openTools on turn-ended and on the forced resend: a dropped
or differently-keyed completion would otherwise pin the turn to the
10-minute budget and name a tool that had already finished.
Harden env parsing: Number("abc") is NaN and NaN <= 0 is false, so the
old guard passed and setTimeout(fn, NaN) fired immediately, stalling
every turn. Non-finite values now fall back to the default; an empty
string still disables, preserving the existing escape hatch.
A setTimeout delay is stored as a signed 32-bit int, so a value above 2147483647 overflows and Node silently clamps it to 1ms. envMs rejected NaN and Infinity but passed every other finite value straight through, so OPENCODE_CURSOR_TOOL_STALL_MS=999999999999 stalled every tool-bearing turn within milliseconds while reporting "no events for 999999999999ms". The tool-phase stall message tells operators to raise that same variable, so the trap was reachable by following the plugin's own advice. Both budgets are now capped at 2147483647. Verified against a live agent: with the over-large budget set, a 150s shell call died after 5.6s before this change and completes normally after it.
justin-carper
force-pushed
the
fix/stall-watchdog-long-tools
branch
from
July 30, 2026 17:57
80f7409 to
0556e8c
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
A turn running a long tool call dies with:
That message comes from the
anyEvent === truebranch ofonStall— it's terminal: the run is cancelled and the turn is lost, with no retry.The stream watchdog re-arms only from
push(), which fires for the ten update types mapped inonDelta's switch. Betweentool-call-startedandtool-call-completeda long shell command, build, or test suite streams nothing at all, so a perfectly healthy run trips the 60s budget. Raising the constant alone doesn't fix it — any fixed ceiling still kills a long-enough tool, while making a genuinely wedged run take longer to surface.The switch also has no
defaultcase, so any update type the plugin doesn't model is dropped and never counts as liveness.Fix
Two budgets.
armWatchdog()picks based on whether any tool call is open:OPENCODE_CURSOR_STALL_MS120000(was60000)0disables the whole watchdogOPENCODE_CURSOR_TOOL_STALL_MS6000000disables just this boundA tool-phase stall stays terminal but now names the culprit:
Re-arm on raw SDK activity.
armWatchdog()is called for everyonDeltaupdate, not just mapped ones, so progress/heartbeat types count as liveness. It runs after the switch so it observes post-mutationopenToolsstate and always selects the correct budget.Reconcile
openTools. Cleared onturn-endedand on a forced resend. Without this, a dropped or differently-keyed completion pins the turn to the 10-minute budget and names a tool that already finished.Env parsing hardening.
Number("abc")isNaN, andNaN <= 0isfalse— so the old guard passed andsetTimeout(fn, NaN)fired immediately, stalling every turn. Non-finite values now fall back to the default. An empty string still yields0(disabled), preserving the existing escape hatch.Testing
npm run typecheckclean ·npm test367 passed ·npm run buildsucceeds.10 new tests, including 3 explicit negative controls that prove the positive results come from the fix rather than the harness (e.g. the same timeline minus the intervening update does stall).
Each production change was mutation-tested — reverted individually to confirm a test actually fails:
armWatchdog()openTools.deleteon completionopenTools.clear()onturn-endedNumber.isFiniteguard120000→60000The 3 pre-existing watchdog tests are unchanged and still pass.
Scope
Deliberately excludes the Cursor SDK log-line leak — #85 covers that, and its approach (routing to
client.app.log, plus sidecar coverage) is the better one.Two suggestions for #85 from investigating the same area:
test/e2e/transport.e2e.ts:31-35maps a bareJSON.parseover every stdout line with notry/catch, so one leaked line fails the suite with an opaque parse error instead of a useful assertion. It's the only e2e guard for that bug class.HH:MM:SS.mmmprefix — that timestamp is an SDK option we don't control, and feat: structured logging for provider diagnostics + Cursor SDK rules/skills logs #85's own sample output doesn't show it.Known limitations
OPENCODE_CURSOR_DEBUG=1already counts every update type if this ever needs revisiting.await runHolder.run?.cancel()infailTerminalis unbounded. Pre-existing, but the tool-phase path makes it more reachable — a wedged run is exactly the case wherecancel()may not settle. Worth its own change.CURSOR_API_KEYwas available. The tool-phase-vs-idle-phase diagnosis is inferred from the error string and the reported symptom (a long-running command); the fix covers both phases so either diagnosis is handled, but an end-to-end run with a multi-minute tool would confirm it.